SCM Latam - Data Science Challenge¶

Nombre: Daniel Torres Guzmán
Fecha de Entrega: 17 de octubre de 2024

Bibliotecas¶

In [1]:
# Importación de bibliotecas necesarias

import json
import pandas as pd
import numpy as np
import mip

import mlflow
import mlflow.sklearn


from sklearn.datasets import make_regression
from sklearn.model_selection import train_test_split, GridSearchCV

from sklearn.linear_model import LinearRegression
from sklearn.neural_network import MLPRegressor
from sklearn.tree import DecisionTreeRegressor
from sklearn.neighbors import KNeighborsRegressor
from sklearn.ensemble import GradientBoostingRegressor

from sklearn.metrics import mean_squared_error, mean_absolute_error, r2_score
from sklearn.preprocessing import StandardScaler

import plotly.express as px
import plotly.graph_objects as go
import plotly.figure_factory as ff
import plotly.subplots as sp

import warnings

warnings.filterwarnings("ignore")

Problema 1: Pregunta de optimización ¶

Descripción del Problema¶

Un hospital de Ciudad Gótica requiere generar horarios para los enfermeros de emergencia durante un periodo de 28 días, distribuidos en tres tipos de turnos:

  • AM: 7:00 hrs a 15:00 hrs
  • PM: 15:00 hrs a 23:00 hrs
  • NOCHE: 23:00 hrs a 7:00 hrs

Actividad 1¶

Carga de Datos¶

Se carga el archivo de entrada Input_Challenge.json que contiene la demanda de personal por turno y día, así como el número total de enfermeros disponibles.

In [2]:
# Carga de los datos desde el archivo json
with open('Input_Challenge.json', 'r') as file:
    data = json.load(file)

demand = data["Demand"]
collabs = data["Collabs"]
shift_collab = data["Shift_Collab"]
shift_patterns = data["Shift_Patterns"]

Formulación del Modelo de Optimización¶

Conjuntos¶

  • $N$: Conjunto de enfermeros de emergencia.
  • $D$: Conjunto de dias.
  • $S$: Conjunto de tipos de turno.
  • $P$: Conjuntos de patrones de turno mensuales.

Parametros¶

  • $\text{U}_{n,p}$: 1 si el enfermero $n$ puede llevar a cabo el patron de turnos $p$, 0 de otra forma.
  • $\text{A}_{p,d,s}$: 1 si el patron $p$ asigna un turno de tipo $s$ el día $d$, 0 de otra forma.
  • $E_{d,s}$: Cantidad necesaria de enfermeros para cubrir demanda del día $d$ en el turno $s$.

Variables de Decision¶

  • $x_{n,d,s}$: 1 si el enfermero $n$ esta asignado el día $d$ al turno $s$.
  • $y_{n,p}$: 1 si el enfermero $n$ esta asignado al patrón de turnos $p$, 0 de otra forma.
  • $z_{d,s} \in \mathbb{Z}$: Variable auxiliar de tipo entero usada para emular el comportamiento de la función valor absoluto.

Funciones¶

  • $\text{Escasez}_{d,s}$: $E_{d,s}- \sum \limits_{n \in N} x_{n,d,s}$
  • $\text{Exceso}_{d,s}$: $\sum \limits_{n \in N} x_{n,d,s} - E_{d,s}$

Restricciones duras¶

  • Cada enfermero debe respetar el patron de turnos asignado

$ x_{n,d,s} = \sum \limits_{p \in P} (y_{n,p} \cdot A_{p,d,s}) ~~~~~~~~~~~~~ \forall n \in N, \forall d \in D, \forall s \in S $

  • Cada enfermero solo puede tomar un turno diario

$ \sum \limits_{s \in S} x_{n,d,s} \leq 1 ~~~~~~~~~~~~~ \forall n \in N, \forall d \in D $

  • Cada enfermero solo puede ser asignado a patrones de turno que sean admitidos por $U_{n,p}$

$ y_{n,p} \leq \text{U}_{n,p} ~~~~~~~~~~~~~ \forall n \in N, \forall p \in P $

  • Cada enfermero solo puede ser asignado a un patron de turnos

$ \sum \limits_{p \in P} y_{n,p} \leq 1 ~~~~~~~~~~~~~ \forall n \in N $

  • Debido a que $\text{Exceso}_{d,s} = -\text{Escasez}_{d,s}$, para obtener el valor absoluto y utilizarlo como elemento a minimizar, se generan las siguientes desigualdades:

$z_{d,s} \geq E_{d,s} - \sum \limits_{n \in N} x_{n,d,s} ~~~~~~~~~~~~~ \forall d \in D, \forall s \in S$

$z_{d,s} \geq -(E_{d,s} - \sum \limits_{n \in N} x_{n,d,s}) ~~~~~~~~~~~~~ \forall d \in D, \forall s \in S$

Función Objetivo¶

Debido a que $z_{d,s}$ representa tanto escasez como exceso, la función objetivo queda como:

$ \text{Minimizar} \sum \limits_{d \in D} \sum \limits_{s \in S} z_{d,s} $

Creacion modelo MIP¶

In [3]:
N = np.arange(len(collabs.values()))
D = np.arange(28)
P = np.arange(len(shift_patterns.keys()))
S = np.arange(3)

U = np.zeros((N.shape[0], P.shape[0]), dtype = int)
for n in N:
    for u in shift_collab[str(n)]:
        U[n][u] = 1

A = np.zeros((P.shape[0], D.shape[0], S.shape[0]), dtype = int)
for p in P:
    A[p] = np.array(shift_patterns[str(p)])

E = np.zeros((D.shape[0], S.shape[0]), dtype = int)
for i,j in enumerate(demand):
    dia = i//3
    turno = i%3
    E[dia][turno] = j
In [4]:
m = mip.Model(sense=mip.MINIMIZE, solver_name=mip.CBC)

# Variable de Decision
x = [[[m.add_var(name='x', var_type=mip.BINARY) for s in range(S.shape[0])] for d in range(D.shape[0])] for n in range(N.shape[0])]
y = [[m.add_var(name='y', var_type=mip.BINARY) for p in range(P.shape[0])] for n in range(N.shape[0])]
z = [[m.add_var(name='Z', var_type=mip.INTEGER) for s in range(S.shape[0])] for d in range(D.shape[0])]
# Restricciones

# Cada enfermero debe respetar el patron de turnos asignado
for n in N:
    for d in D:
        for s in S:
            m += x[n][d][s] - mip.xsum((A[p][d][s] * y[n][p]) for p in P) == 0

# Cada enfermero solo puede tomar un turno diario
for n in N:
    for d in D:
         m += mip.xsum(x[n][d][s] for s in S) <= 1

# Cada enfermero solo puede ser asignado a patrones de turno que sean admitidos
for n in N:
    for p in P:
        m += y[n][p] <= U[n][p]

# Cada enfermero solo puede ser asignado a un set de turnos

for n in N:
    m+= mip.xsum(y[n][p] for p in P) <= 1

# Todo turno debe tener que estar cubierto por al menos 1 enfermero

# for d in D:
#     for s in S:
#         m+= mip.xsum(x[n][d][s] for n in N) >= 1

for d in D:
    for s in S:
        m+= z[d][s] >= E[d][s] - mip.xsum(x[n][d][s] for n in N)
        m+= z[d][s] >= -(E[d][s] - mip.xsum(x[n][d][s] for n in N))

m.objective = mip.xsum(z[d][s] for d in D for s in S)
In [5]:
m.optimize()
if m.status == mip.OptimizationStatus.OPTIMAL:
    print(f'costo de solución óptima {m.objective_value} encontrado')
elif m.status == mip.OptimizationStatus.FEASIBLE:
    print(f'costo de solución {m.objective_value} encontrado, mejor posible: {m.objective_bound}')
else:
    print(f'soluciones factibles no encontradas, la cota inferior es: {m.objective_bound}')
if m.status == mip.OptimizationStatus.OPTIMAL or m.status == mip.OptimizationStatus.FEASIBLE:
    y_sol = {"result":[]}
    for i in range(16):
        for j in range(100):
            if y[i][j].x == 1.0:
                y_sol["result"].append([i,j])

print(y_sol)     
costo de solución óptima 95.0 encontrado
{'result': [[1, 48], [2, 44], [3, 16], [4, 63], [5, 83], [6, 97], [7, 91], [8, 14], [9, 52], [11, 50], [12, 0], [13, 8], [15, 4]]}

Resultados¶

El resultado óptimo para el caso entregado es:

[[1, 48], [2, 44], [3, 16], [4, 63], [5, 83], [6, 97], [7, 91], [8, 14], [9, 52], [11, 50], [12, 0], [13, 8], [15, 4]]

Actividad 2¶

  1. Reducir los patrones de un ámbito mensual a uno semanal

Reducir el horizonte de planificación de mensual a semanal podría ofrecer una mayor flexibilidad en la programación de turnos. Esto permitiría planificar en periodos más cortos, lo que podría aumentar el espacio de búsqueda y permitir soluciones más ágiles que se ajusten mejor a las necesidades cambiantes de los enfermeros y del sistema. Esta modificación ayudaría a dar mayor holgura a la asignación de turnos y a adaptarse mejor a las variaciones en la disponibilidad y demanda.

  1. Aumentar los grados de libertad en los patrones de turnos

Una mejora importante sería flexibilizar la noción de patrones mensuales de turnos, que en su forma actual puede resultar demasiado restrictiva. En lugar de depender únicamente de patrones predefinidos, se podría extraer información clave para adaptar mejor los turnos a las necesidades individuales de cada enfermero. Algunos elementos que podrían incorporarse son:

  • Número mínimo de días libres consecutivos para cada enfermero.
  • Número máximo de días consecutivos trabajados.
  • Días en los que el enfermero no pueda trabajar debido a compromisos previos o restricciones personales.

Al incluir estas condiciones, el modelo tendría más grados de libertad para encontrar soluciones factibles, que no solo cumplan con las restricciones operativas sino que también tengan en cuenta las preferencias individuales. Esto podría mejorar tanto la eficiencia del sistema como la satisfacción laboral de los enfermeros, ampliando el espacio de búsqueda para encontrar mejores asignaciones.

  1. Incorporar las preferencias de los enfermeros

Si bien las preferencias de los enfermeros pueden verse como restricciones adicionales, es posible tratarlas como restricciones "blandas" que, aunque no son estrictamente necesarias, pueden mejorar considerablemente el ambiente laboral. Algunas preferencias que podrían tenerse en cuenta incluyen:

  • Días preferidos por cada enfermero para trabajar.
  • Turnos preferidos (de día o de noche, por ejemplo).
  • Días libres preferidos o deseados.

Considerar estas preferencias no solo mejora el modelo desde el punto de vista del bienestar de los empleados, sino que también puede llevar a un aumento en la retención de personal y la satisfacción general, lo que a largo plazo puede traducirse en un servicio más eficiente y motivado.

  1. Uso de demandas dinámicas

Aunque el problema actual se aborda de forma estática, existe la posibilidad de que la demanda de personal siga un comportamiento cíclico a lo largo de periodos específicos, como semanas, meses o años. Para abordar esto, se podría considerar el uso de modelos predictivos, como deep neural networks, junto a datos historicos, para anticipar la demanda futura.

Al hacer la demanda más dinámica y ajustada a predicciones futuras, se podrían generar planes de turnos más ajustados a las necesidades del sistema de salud, optimizando la asignación de enfermeros y evitando escasez o exceso de personal en ciertos periodos. Esto también ayudaría a mejorar la capacidad de respuesta del sistema ante variaciones en la carga de trabajo.

Actividad 3¶

In [6]:
# Inicializa arreglos de dias, demanda y asignacion
plot_days = np.arange(28, dtype=int)
plot_demand = np.zeros((28, 3), dtype=int)  # 3 columnas para turnos AM, PM, Noche
plot_total_demand = np.zeros(28, dtype=int) 
plot_asignacion = np.zeros((28, 3), dtype=int)
plot_total_asignacion = np.zeros(28, dtype=int)

# Reemplazo valores de demanda directamente con slicing
plot_demand[:, :3] = E[:, :3]
for j in range(28):
    plot_total_demand[j] = np.sum(E[j, :])

# Actualizo las asignaciones retornadas por el modelo
for i in y_sol["result"]:
    shift_pattern = shift_patterns[str(i[1])]
    shift_array = np.array(shift_pattern)

    # suma de asignaciones por turno
    plot_asignacion[:, 0] += shift_array[:, 0]  # AM shift
    plot_asignacion[:, 1] += shift_array[:, 1]  # PM shift
    plot_asignacion[:, 2] += shift_array[:, 2]  # Noche shift

    for j in range(28):
        plot_total_asignacion[j] += np.sum(shift_array[j, :])
In [7]:
demand_shift_am = plot_demand[:, 0]
demand_shift_pm = plot_demand[:, 1]
demand_shift_noche = plot_demand[:, 2]

asignacion_turno_am = plot_asignacion[:, 0]
asignacion_turno_pm = plot_asignacion[:, 1]
asignacion_turno_noche = plot_asignacion[:, 2]

# Crear un DataFrame para cada turno
data_am = pd.DataFrame({
    'Día': plot_days,
    'Demanda': demand_shift_am,
    'Asignación': asignacion_turno_am,
})

data_pm = pd.DataFrame({
    'Día': plot_days,
    'Demanda': demand_shift_pm,
    'Asignación': asignacion_turno_pm,
})

data_noche = pd.DataFrame({
    'Día': plot_days,
    'Demanda': demand_shift_noche,
    'Asignación': asignacion_turno_noche,
})

data_total = pd.DataFrame({
    'Día': plot_days,
    'Demanda': plot_total_demand,
    'Asignación': plot_total_asignacion,
})

# Gráfico para el turno AM
fig_am = px.line(data_am, x='Día', y=['Demanda', 'Asignación'], 
                 title='Demanda vs Asignación para el Turno AM',
                 labels={'value': 'Valores', 'variable': 'Tipo'})
fig_am.update_traces(marker=dict(size=8))
fig_am.show()

# Gráfico para el turno PM
fig_pm = px.line(data_pm, x='Día', y=['Demanda', 'Asignación'], 
                 title='Demanda vs Asignación para el Turno PM',
                 labels={'value': 'Valores', 'variable': 'Tipo'})
fig_pm.update_traces(marker=dict(size=8))
fig_pm.show()

# Gráfico para el turno Noche
fig_noche = px.line(data_noche, x='Día', y=['Demanda', 'Asignación'], 
                    title='Demanda vs Asignación para el Turno Noche',
                    labels={'value': 'Valores', 'variable': 'Tipo'})
fig_noche.update_traces(marker=dict(size=8))
fig_noche.show()

# Gráfico para el total de demanda
fig_noche = px.line(data_total, x='Día', y=['Demanda', 'Asignación'], 
                    title='Demanda vs Asignación para el total de la demanda',
                    labels={'value': 'Valores', 'variable': 'Tipo'})
fig_noche.update_traces(marker=dict(size=8))
fig_noche.show()

Se observa que todos los turnos presentan una demanda promedio, junto con máximos y mínimos locales que surgen esporádicamente. El uso de patrones de turnos dificulta la cobertura perfecta de la demanda, generando una variabilidad notable en la asignación de enfermeros, especialmente en días de alta demanda, donde se alcanzan puntuaciones elevadas de escasez. Los gráficos de los turnos AM y PM refuerzan que estos patrones mensuales no permiten una cobertura adecuada, mostrando asignaciones erráticas tanto en exceso como en escasez de personal. Esta irregularidad resalta la necesidad de reconsiderar los patrones de turno o implementar un sistema que ofrezca mayor flexibilidad en la asignación.

Problema 2: Pregunta de Machine Learning¶

Un supermercado desea saber cuál es la cantidad de ventas (transacciones) que tendrá en el mes de octubre. Para esto, contrata a SCM con el fin de obtener esta información del futuro.

Actividad 1¶

Carga y limpieza de datos¶

In [8]:
df = pd.read_excel('TRX_SUPERMERCADO.xlsx', header=0)

df.head()
Out[8]:
Fecha Driver 00:00 00:15 00:30 00:45 01:00 01:15 01:30 01:45 ... 21:30 21:45 22:00 22:15 22:30 22:45 23:00 23:15 23:30 23:45
0 2024-07-01 CANTIDAD_TRX 0 0 0 0 0 0 0 0 ... 24.0 26 13 0 0 0 0 0 0 0
1 2024-07-01 TRX_EFECTIVO 0 0 0 0 0 0 0 0 ... 0.0 0 0 0 0 0 0 0 0 0
2 2024-07-01 TRX_TARJETA 0 0 0 0 0 0 0 0 ... 0.0 0 0 0 0 0 0 0 0 0
3 2024-07-02 CANTIDAD_TRX 0 0 0 0 0 0 0 0 ... 23.0 18 16 2 0 0 0 0 0 0
4 2024-07-02 TRX_EFECTIVO 0 0 0 0 0 0 0 0 ... 6.0 4 6 0 0 0 0 0 0 0

5 rows × 98 columns

Al revisar el resultado de df.head(), se destaca de inmediato la presencia de campos mal calculados, como los tres registros correspondientes al 2024-07-01 a las 21:30.

Análisis de Errores en Tipos de Datos¶

In [9]:
df.select_dtypes(exclude=['int64', 'float64']).info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 268 entries, 0 to 267
Data columns (total 3 columns):
 #   Column  Non-Null Count  Dtype         
---  ------  --------------  -----         
 0   Fecha   268 non-null    datetime64[ns]
 1   Driver  268 non-null    object        
 2   11:45   267 non-null    object        
dtypes: datetime64[ns](1), object(2)
memory usage: 6.4+ KB

El hecho de que la columna "11:45" sea de tipo string sugiere la presencia de datos no numéricos en alguna de las variables, lo cual requiere ser investigado y corregido.

In [10]:
# se reemplaza el error
try:
    pd.to_numeric(df['11:45'], errors='raise')
except Exception as e:
    print(e)
Unable to parse string "dos" at position 80
In [11]:
# Se reemplaza manualmente el dato no numerico
df.at[80,"11:45"] = 2
df['11:45'] = pd.to_numeric(df['11:45'], errors='raise')
df.select_dtypes(exclude=['int64', 'float64']).info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 268 entries, 0 to 267
Data columns (total 2 columns):
 #   Column  Non-Null Count  Dtype         
---  ------  --------------  -----         
 0   Fecha   268 non-null    datetime64[ns]
 1   Driver  268 non-null    object        
dtypes: datetime64[ns](1), object(1)
memory usage: 4.3+ KB

Limpieza de datos nulos¶

In [12]:
# Identificar y tratar datos nulos o mal calculados

null_rows = df[df.isnull().any(axis=1)]
null_rows
Out[12]:
Fecha Driver 00:00 00:15 00:30 00:45 01:00 01:15 01:30 01:45 ... 21:30 21:45 22:00 22:15 22:30 22:45 23:00 23:15 23:30 23:45
47 2024-07-17 TRX_EFECTIVO 0 0 0 0 0 0 0 0 ... 9.0 9 4 0 0 0 0 0 0 0
123 2024-08-12 TRX_TARJETA 0 0 0 0 0 0 0 0 ... 25.0 14 9 2 0 0 0 0 0 0
130 2024-08-15 CANTIDAD_TRX 0 0 0 0 0 0 0 0 ... NaN 29 18 1 0 0 0 0 0 0
159 2024-08-24 TRX_TARJETA 0 0 0 0 0 0 0 0 ... NaN 21 9 0 0 0 0 0 0 0
259 2024-09-27 CANTIDAD_TRX 0 0 0 0 0 0 0 0 ... 19.0 21 12 0 0 0 0 0 0 0

5 rows × 98 columns

In [13]:
# Todos los valores nulos estan acompa;ados de los demas valores en la fecha, por lo que pueden ser calculados
null_rows = df[df.isnull().any(axis=1)]

for i, row in null_rows.iterrows():
    rows_fecha = df[df["Fecha"] == row["Fecha"]]
    null_col = row[row.isnull()].index
    for nc in null_col:
        if row["Driver"] == "CANTIDAD_TRX":
            valor = rows_fecha[rows_fecha["Driver"] != "CANTIDAD_TRX"][nc].sum()
            df.at[i, nc] = valor
            
        elif row["Driver"] == "TRX_EFECTIVO":
            # Calculate value as CANTIDAD_TRX - TRX_TARJETA
            cantidad_trx = rows_fecha[rows_fecha["Driver"] == "CANTIDAD_TRX"][nc].values[0]
            trx_tarjeta = rows_fecha[rows_fecha["Driver"] == "TRX_TARJETA"][nc].values[0]
            valor = cantidad_trx - trx_tarjeta
            df.at[i, nc] = valor
            
        elif row["Driver"] == "TRX_TARJETA":
            # Calculate value as CANTIDAD_TRX - TRX_EFECTIVO
            cantidad_trx = rows_fecha[rows_fecha["Driver"] == "CANTIDAD_TRX"][nc].values[0]
            trx_efectivo = rows_fecha[rows_fecha["Driver"] == "TRX_EFECTIVO"][nc].values[0]
            valor = cantidad_trx - trx_efectivo
            df.at[i, nc] = valor
In [14]:
null_rows = df[df.isnull().any(axis=1)]
null_rows
Out[14]:
Fecha Driver 00:00 00:15 00:30 00:45 01:00 01:15 01:30 01:45 ... 21:30 21:45 22:00 22:15 22:30 22:45 23:00 23:15 23:30 23:45

0 rows × 98 columns

Limpieza de calculos incorrectos CANTIDAD_TRX¶

In [15]:
unique_fechas = df.Fecha.unique()
for uf in unique_fechas:
    rows_fecha = df[df["Fecha"] == uf]
    h_cols = df.columns[2:]
    for hc in h_cols:
        b = [rows_fecha[rows_fecha["Driver"] == "CANTIDAD_TRX"][hc].values.size == 0,
            rows_fecha[rows_fecha["Driver"] == "TRX_EFECTIVO"][hc].values.size == 0,
            rows_fecha[rows_fecha["Driver"] == "TRX_TARJETA"][hc].values.size == 0]
        
        if b[0] and not (b[1] and b[2]):
            # Si falta "CANTIDAD_TRX" pero no faltan "TRX_EFECTIVO" ni "TRX_TARJETA"
            # Creamos una nueva fila para esa fecha con la suma de las otras columnas
            new_row = {
                "Fecha": uf,
                "Driver": "CANTIDAD_TRX"  # Suponiendo que queremos etiquetar esta fila como "CANTIDAD_TRX"
            }
            for col in h_cols:
                # Sumar los valores de "TRX_EFECTIVO" y "TRX_TARJETA"
                new_row[col] = rows_fecha[rows_fecha["Driver"] == "TRX_EFECTIVO"][col].sum() + rows_fecha[rows_fecha["Driver"] == "TRX_TARJETA"][col].sum()
                               
            df = df.append(new_row, ignore_index=True)  # Agregamos la nueva fila al dataframe
        elif b[0] and not (b[1] or b[2]):
            # Si falta "CANTIDAD_TRX" y también falta "TRX_EFECTIVO" o "TRX_TARJETA"
            # Eliminamos todas las filas correspondientes a esa fecha
            df = df[df["Fecha"] != uf]  # Eliminamos filas de esa fecha
            break
        elif not b[0] and (b[1] or b[2]):
            continue
        cantidad_trx = rows_fecha[rows_fecha["Driver"] == "CANTIDAD_TRX"][hc].values[0]
        trx_tarjeta = rows_fecha[rows_fecha["Driver"] == "TRX_TARJETA"][hc].values[0]
        trx_efectivo = rows_fecha[rows_fecha["Driver"] == "TRX_EFECTIVO"][hc].values[0]
        
        if trx_tarjeta + trx_efectivo > cantidad_trx:
            cantidad_trx = trx_tarjeta + trx_efectivo
        df.loc[(df["Driver"] == "CANTIDAD_TRX") & (df["Fecha"] == uf), hc] = cantidad_trx

df.head()
Out[15]:
Fecha Driver 00:00 00:15 00:30 00:45 01:00 01:15 01:30 01:45 ... 21:30 21:45 22:00 22:15 22:30 22:45 23:00 23:15 23:30 23:45
0 2024-07-01 CANTIDAD_TRX 0 0 0 0 0 0 0 0 ... 24.0 26 13 0 0 0 0 0 0 0
1 2024-07-01 TRX_EFECTIVO 0 0 0 0 0 0 0 0 ... 0.0 0 0 0 0 0 0 0 0 0
2 2024-07-01 TRX_TARJETA 0 0 0 0 0 0 0 0 ... 0.0 0 0 0 0 0 0 0 0 0
3 2024-07-02 CANTIDAD_TRX 0 0 0 0 0 0 0 0 ... 23.0 18 16 2 0 0 0 0 0 0
4 2024-07-02 TRX_EFECTIVO 0 0 0 0 0 0 0 0 ... 6.0 4 6 0 0 0 0 0 0 0

5 rows × 98 columns

En relación a la discrepancia de valores entre TRX_TARJETA + TRX_EFECTIVO y CANTIDAD_TRX, resulta extraño que un sistema de información automatizado en un supermercado registre datos erróneos de esta forma. Una posible explicación es que el registro de transacciones esté a cargo de una persona, lo que podría llevar a errores humanos. Esto me lleva a la conclusión de que, en algunas ocasiones, el operador olvida registrar parte de la información, lo que genera estas inconsistencias. En este análisis, siempre consideré el valor mayor como el real, ya que es más probable que el operador omita el registro de una transacción a que ingrese de forma incorrecta múltiples veces el mismo valor. Por lo tanto, si TRX_TARJETA + TRX_EFECTIVO es mayor que CANTIDAD_TRX, se corregirá CANTIDAD_TRX para igualar la suma de ambos.

Exploracion de los datos¶

In [16]:
columnas_horas = df.columns[2:]

grupo_horas = [columnas_horas[i:i+4] for i in range(0, len(columnas_horas), 4)]
transacciones_por_hora_grupo = pd.DataFrame()

for i, grupo in enumerate(grupo_horas):
    transacciones_por_hora_grupo[f'{i:02d}:00-{i+1:02d}:00'] = df[df["Driver"] == "CANTIDAD_TRX"][grupo].sum(axis=1)

transacciones_acumuladas = transacciones_por_hora_grupo.sum()

fig = px.bar(x=transacciones_acumuladas.index, y=transacciones_acumuladas.values, 
             labels={'x': 'Intervalos de Horas', 'y': 'Cantidad de Transacciones'},
             title='Distribución de Transacciones Acumuladas por Intervalos de 1 Hora')

fig.update_layout(xaxis_tickangle=-45)
fig.show()

El gráfico que muestra la distribución acumulada de transacciones por intervalos de una hora revela un patrón distintivo en el comportamiento de las compras a lo largo del día. Se observa un aumento gradual en el número de transacciones, alcanzando un pico notable alrededor de las 13:00. Este incremento puede atribuirse a los horarios de colación, donde las personas tienden a realizar más compras. Posteriormente, se presenta un segundo pico a las 19:00, lo que sugiere un aumento en la actividad de compras relacionado con la salida del trabajo y el regreso a casa. Este análisis resalta la influencia de los horarios laborales en los patrones de consumo.

In [17]:
df['Dia_Semana'] = df['Fecha'].dt.dayofweek  # Returns integer from 0 to 6

transacciones_por_dia = df[df["Driver"] == "CANTIDAD_TRX"].groupby('Dia_Semana')[columnas_horas].sum().sum(axis=1)

dias_orden = ['Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo']
transacciones_por_dia = transacciones_por_dia.reindex(range(7))  # Ensure all days are present
transacciones_por_dia.index = dias_orden  # Change index to names of the days

fig = px.bar(x=transacciones_por_dia.index, y=transacciones_por_dia.values,
             labels={'x': 'Día de la Semana', 'y': 'Cantidad de Transacciones'},
             title='Distribución de Transacciones Acumuladas por Día de la Semana')

fig.update_layout(xaxis_tickangle=-45)  # Rotate x-axis labels for better readability
fig.show()
In [18]:
df['Semana_Año'] = df['Fecha'].dt.isocalendar().week  # Returns the week number

# Sum transactions per week
transacciones_por_semana = df[df["Driver"] == "CANTIDAD_TRX"].groupby('Semana_Año')[columnas_horas].sum().sum(axis=1)

# Convert the seaborn barplot to plotly bar chart
fig = px.bar(x=transacciones_por_semana.index, y=transacciones_por_semana.values,
             labels={'x': 'Semana del Año', 'y': 'Cantidad de Transacciones'},
             title='Distribución de Transacciones Acumuladas por Semana')

fig.update_layout(xaxis_tickangle=-45)  # Rotate x-axis labels for better readability
fig.show()
In [19]:
df_trx = df[df["Driver"] == "CANTIDAD_TRX"]

# Calcular las transacciones totales por fecha (sumando por filas las columnas horarias)
df_trx['Total_Transacciones'] = df_trx[columnas_horas].sum(axis=1)

# Crear el gráfico de barras con Plotly
fig = px.bar(df_trx, x='Fecha', y='Total_Transacciones',
             labels={'Fecha': 'Fecha', 'Total_Transacciones': 'Cantidad de Transacciones'},
             title='Transacciones Acumuladas por Fecha')

# Ajustar el layout para mejorar la legibilidad
fig.update_layout(xaxis_tickangle=-45, width=1000, height=600)

# Mostrar el gráfico
fig.show()

El análisis de los gráficos muestra patrones interesantes respecto a las transacciones a lo largo del tiempo. En el primer gráfico, que representa las transacciones diarias distribuidas por día de la semana, se aprecia un leve aumento hacia la mitad de la semana, con un ligero descenso hacia los fines de semana. Sin embargo, esta tendencia no es completamente clara, lo que dificulta establecer una conclusión precisa sobre la distribución de las transacciones.

Por otro lado, el tercer gráfico, que abarca las transacciones desde el 01/07/2024 hasta el 29/09/2024, replica de manera más evidente el comportamiento observado en el primero. Aquí, el aumento de transacciones a mitad de cada semana es mucho más claro, pero además revela una tendencia general donde las transacciones disminuyen gradualmente cada semana, alcanzando su punto más bajo los domingos. Este patrón se repite consistentemente hasta finales de cada mes, siendo especialmente evidente en los meses de julio y la primera mitad de agosto. El gráfico agrupado por semanas refuerza la idea de un comportamiento estacional hacia septiembre, donde las Fiestas Patrias parecen influir en la estabilización del patrón de descenso semanal.

A partir de mediados de agosto, el comportamiento de las transacciones comienza a estancarse, y el patrón de disminución semanal deja de ser tan pronunciado. Aunque se conserva la tendencia semanal observada en el primer gráfico, el comportamiento general de las transacciones por semana parece estabilizarse. Considerando que en Chile este periodo coincide con la preparación para las Fiestas Patrias en septiembre, es posible que este cambio en el patrón se deba a un factor estacional, donde el aumento de la actividad económica por estas festividades genera una estabilización en el descenso de transacciones durante el mes.

In [20]:
df_grouped = df[df["Driver"] != "CANTIDAD_TRX"].groupby(['Fecha', 'Driver'])[columnas_horas].sum().sum(axis=1).unstack()

# Filtrar los datos para cada tipo de transacción
df_tarjeta = df_grouped[['TRX_TARJETA']]
df_efectivo = df_grouped[['TRX_EFECTIVO']]

fig = sp.make_subplots(rows=1, cols=2, shared_yaxes=True, 
                    subplot_titles=("Transacciones con Tarjeta", "Transacciones en Efectivo"))

# Añadir la barra de TRX_TARJETA al primer subplot (columna 1)
fig.add_trace(go.Bar(
    x=df_tarjeta.index,
    y=df_tarjeta['TRX_TARJETA'],
    name='TRX_TARJETA',
    hoverinfo='y',
), row=1, col=1)

# Añadir la barra de TRX_EFECTIVO al segundo subplot (columna 2)
fig.add_trace(go.Bar(
    x=df_efectivo.index,
    y=df_efectivo['TRX_EFECTIVO'],
    name='TRX_EFECTIVO',
    hoverinfo='y',
), row=1, col=2)

# Actualizar el layout
fig.update_layout(
    title_text='Comparación de Transacciones con Tarjeta y Efectivo a lo Largo del Tiempo',
    xaxis_title='Fecha',
    yaxis_title='Cantidad de Transacciones',
    barmode='group',
    width=1000,
    height=600,
)

# Rotar las etiquetas del eje X
fig.update_xaxes(tickangle=-45)

# Mostrar el gráfico
fig.show()

Ambos gráficos parecen mostrar una tendencia general donde los volúmenes de transacciones varían de manera sincrónica. En ciertos períodos (como a mediados de julio y agosto), los picos y valles en la cantidad de transacciones son similares tanto para tarjetas como para efectivo, lo que sugiere que factores externos como la estacionalidad o eventos específicos afectan de manera similar a ambas formas de pago.

In [21]:
# Filtrar las transacciones por tipo
transacciones_totales = df[df["Driver"].isin(["TRX_TARJETA", "TRX_EFECTIVO"])]
transacciones_por_tipo = transacciones_totales.groupby('Driver')[columnas_horas].sum().sum(axis=1)

# Etiquetas y colores para el gráfico
etiquetas = ['Efectivo', 'Tarjeta']
colores = ['#FF5722', '#4CAF50']

# Crear el gráfico circular
fig = go.Figure(data=[go.Pie(
    labels=etiquetas,
    values=transacciones_por_tipo,
    textinfo='label+percent',  # Mostrar etiqueta y porcentaje
    marker=dict(colors=colores),
    hole=0.3,  # Para un gráfico de dona, cambia a 0.3 (opcional)
)])

# Actualizar el layout
fig.update_layout(
    title='Distribución de Transacciones: Tarjeta vs Efectivo',
)

# Mostrar el gráfico
fig.show()

Tanto en el gráfico por fecha como en el gráfico de las transacciones acumuladas durante todo el período, se observa una clara preferencia de los clientes por el uso de tarjetas en lugar de efectivo. Aproximadamente el 70% de todas las transacciones se realizan con tarjeta, lo que sugiere que este método de pago es significativamente más popular.

Las razones de esta preferencia pueden ser diversas y requerirían un estudio más profundo para obtener conclusiones definitivas. Sin embargo, factores como la seguridad que ofrece el pago con tarjeta, la facilidad de uso y la capacidad de manejar grandes cantidades de dinero sin necesidad de transportar efectivo físico probablemente juegan un papel importante en esta tendencia.

In [22]:
df.drop(columns=['Dia_Semana'], inplace=True)
df.drop(columns=['Semana_Año'], inplace=True)

df_original = df.copy()
In [23]:
df = df_original.copy()

mapping = {
    'CANTIDAD_TRX': 0,
    'TRX_TARJETA': 1,
    'TRX_EFECTIVO': 2
}

# Reemplazar los valores en la columna 'Driver' usando el mapeo
df['Driver'] = df['Driver'].replace(mapping)

tiempos = [f'{str(i).zfill(2)}:{str(j).zfill(2)}' for i in range(24) for j in range(0, 60, 15)]

# Crear un nuevo DataFrame para almacenar las filas reestructuradas
data = []

# Iterar sobre cada fila del DataFrame original
for index, row in df.iterrows():
    fecha = row['Fecha']
    driver = row['Driver']
    
    # Crear una fila para cada columna de tiempo
    for tiempo in tiempos:
        # Obtener la cantidad de transacciones para esa hora
        transacciones = row[tiempo]
        
        # Agregar la información al nuevo DataFrame
        data.append([fecha, driver, tiempo, transacciones])

# Crear un nuevo DataFrame a partir de la lista de datos
df_largo = pd.DataFrame(data, columns=['Fecha', 'Driver', 'Hora_15min', 'Transacciones'])

# Separar la columna 'Hora_15min' en horas y minutos
df_largo['Hora'] = df_largo['Hora_15min'].apply(lambda x: int(x.split(':')[0]))
df_largo['Minuto'] = df_largo['Hora_15min'].apply(lambda x: int(x.split(':')[1]))

# Crear columnas para día, mes y año
df_largo['Dia'] = df_largo['Fecha'].dt.day
df_largo['Mes'] = df_largo['Fecha'].dt.month
df_largo['Año'] = df_largo['Fecha'].dt.year


# Imprimir el DataFrame resultante
print(df_largo.head())
       Fecha  Driver Hora_15min  Transacciones  Hora  Minuto  Dia  Mes   Año
0 2024-07-01       0      00:00            0.0     0       0    1    7  2024
1 2024-07-01       0      00:15            0.0     0      15    1    7  2024
2 2024-07-01       0      00:30            0.0     0      30    1    7  2024
3 2024-07-01       0      00:45            0.0     0      45    1    7  2024
4 2024-07-01       0      01:00            0.0     1       0    1    7  2024
In [24]:
ac = []
for f in df_largo["Fecha"].unique():
    for h in df_largo["Hora_15min"].unique():
        suma_transacciones = df_largo[(df_largo['Fecha'] == f) & (df_largo['Hora_15min'] == h) & (df_largo['Driver'] == 0)]['Transacciones'].sum()
        ac.append([f, suma_transacciones, f.day, f.month, int(h.split(':')[0]), int(h.split(':')[1])])
print(ac[0])
df_desglosado = pd.DataFrame(ac,columns=['Fecha', 'Transacciones', 'Dia', 'Mes', 'Hora', 'Minuto'])

# Debido a que contamos con ciclos completos de mes, semana, 
# se convertira el día del mes, el día de la semana, la hora y los minutos en variables ciclicas
df_desglosado['sin_hora'] = np.sin(2 * np.pi * df_desglosado['Hora'] / 24)
df_desglosado['cos_hora'] = np.cos(2 * np.pi * df_desglosado['Hora'] / 24)

df_desglosado['sin_min'] = np.sin(2 * np.pi * df_desglosado['Minuto'] / 60)
df_desglosado['cos_min'] = np.cos(2 * np.pi * df_desglosado['Minuto'] / 60)

df_desglosado['sin_dia_mes'] = np.sin(2 * np.pi * df_desglosado['Dia'] / 31)
df_desglosado['cos_dia_mes'] = np.cos(2 * np.pi * df_desglosado['Dia'] / 31)


# Calcular variables cíclicas para el día del mes
df_desglosado['sin_dia_mes'] = np.sin(2 * np.pi * df_desglosado['Dia'] / 31)
df_desglosado['cos_dia_mes'] = np.cos(2 * np.pi * df_desglosado['Dia'] / 31)

# Calcular variables cíclicas para el día de la semana (0=lunes, 6=domingo)
# Asegurarse de que el día de la semana esté calculado correctamente
df_desglosado['Dia_Semana'] = df_desglosado['Fecha'].dt.dayofweek  # 0=lunes, 6=domingo
df_desglosado['sin_dia_semana'] = np.sin(2 * np.pi * df_desglosado['Dia_Semana'] / 7)
df_desglosado['cos_dia_semana'] = np.cos(2 * np.pi * df_desglosado['Dia_Semana'] / 7)
df_desglosado.drop(columns=['Fecha','Dia','Mes','Hora','Minuto','Dia_Semana'], axis=1, inplace=True)
df_desglosado
[Timestamp('2024-07-01 00:00:00'), np.float64(0.0), 1, 7, 0, 0]
Out[24]:
Transacciones sin_hora cos_hora sin_min cos_min sin_dia_mes cos_dia_mes sin_dia_semana cos_dia_semana
0 0.0 0.000000 1.000000 0.000000e+00 1.000000e+00 0.201299 0.979530 0.000000 1.00000
1 0.0 0.000000 1.000000 1.000000e+00 2.832769e-16 0.201299 0.979530 0.000000 1.00000
2 0.0 0.000000 1.000000 5.665539e-16 -1.000000e+00 0.201299 0.979530 0.000000 1.00000
3 0.0 0.000000 1.000000 -1.000000e+00 -1.836970e-16 0.201299 0.979530 0.000000 1.00000
4 0.0 0.258819 0.965926 0.000000e+00 1.000000e+00 0.201299 0.979530 0.000000 1.00000
... ... ... ... ... ... ... ... ... ...
8635 0.0 -0.500000 0.866025 -1.000000e+00 -1.836970e-16 -0.394356 0.918958 -0.781831 0.62349
8636 0.0 -0.258819 0.965926 0.000000e+00 1.000000e+00 -0.394356 0.918958 -0.781831 0.62349
8637 0.0 -0.258819 0.965926 1.000000e+00 2.832769e-16 -0.394356 0.918958 -0.781831 0.62349
8638 0.0 -0.258819 0.965926 5.665539e-16 -1.000000e+00 -0.394356 0.918958 -0.781831 0.62349
8639 0.0 -0.258819 0.965926 -1.000000e+00 -1.836970e-16 -0.394356 0.918958 -0.781831 0.62349

8640 rows × 9 columns

Matriz de Corelación¶

In [25]:
# Calcular la matriz de correlación
correlation_matrix = df_desglosado.corr()

# Crear una máscara para el triángulo superior
mask = np.triu(np.ones_like(correlation_matrix, dtype=bool))

# Aplicar la máscara a la matriz de correlación
correlation_matrix_masked = correlation_matrix.mask(mask)

# Crear el mapa de calor usando Plotly
fig = px.imshow(correlation_matrix_masked, text_auto=True)
fig.show()
In [26]:
# Días de la semana
dias_semana = np.arange(7)
sin_dia_semana = np.sin(2 * np.pi * dias_semana / 7)
cos_dia_semana = np.cos(2 * np.pi * dias_semana / 7)
nombres_dias = ['Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo']

# Días del mes
dias_mes = np.arange(1, 32)
sin_dia_mes = np.sin(2 * np.pi * dias_mes / 31)
cos_dia_mes = np.cos(2 * np.pi * dias_mes / 31)

# Horas
horas = np.arange(24)
sin_horas = np.sin(2 * np.pi * horas / 24)
cos_horas = np.cos(2 * np.pi * horas / 24)

# Minutos
minutos = np.arange(0, 60, step=15)
sin_minutos = np.sin(2 * np.pi * minutos / 60)
cos_minutos = np.cos(2 * np.pi * minutos / 60)

# DataFrames
df_semana = pd.DataFrame({
    'Día de la Semana': nombres_dias,
    'sin_dia_semana': sin_dia_semana,
    'cos_dia_semana': cos_dia_semana
})

df_mes = pd.DataFrame({
    'Día del Mes': dias_mes,
    'sin_dia_mes': sin_dia_mes,
    'cos_dia_mes': cos_dia_mes
})

df_horas = pd.DataFrame({
    'Hora': horas,
    'sin_horas': sin_horas,
    'cos_horas': cos_horas
})

df_minutos = pd.DataFrame({
    'Minuto': minutos,
    'sin_minutos': sin_minutos,
    'cos_minutos': cos_minutos
})

# Crear una figura con subgráficos
fig = sp.make_subplots(rows=2, cols=2, 
                        subplot_titles=('Distribución Cíclica de Días de la Semana',
                                        'Distribución Cíclica de Días del Mes',
                                        'Distribución Cíclica de Horas',
                                        'Distribución Cíclica de Minutos'))

# Subplot 1: Días de la semana
fig.add_trace(
    go.Scatter(x=df_semana['sin_dia_semana'], y=df_semana['cos_dia_semana'], 
               mode='markers+text', 
               text=df_semana['Día de la Semana'],
               textposition='top center',
               marker=dict(size=10)),
    row=1, col=1
)

# Subplot 2: Días del mes
fig.add_trace(
    go.Scatter(x=df_mes['sin_dia_mes'], y=df_mes['cos_dia_mes'], 
               mode='markers+text', 
               text=df_mes['Día del Mes'],
               textposition='top center',
               marker=dict(size=10)),
    row=1, col=2
)

# Subplot 3: Horas
fig.add_trace(
    go.Scatter(x=df_horas['sin_horas'], y=df_horas['cos_horas'], 
               mode='markers+text', 
               text=df_horas['Hora'],
               textposition='top center',
               marker=dict(size=10)),
    row=2, col=1
)

# Subplot 4: Minutos
fig.add_trace(
    go.Scatter(x=df_minutos['sin_minutos'], y=df_minutos['cos_minutos'], 
               mode='markers+text', 
               text=df_minutos['Minuto'],
               textposition='top center',
               marker=dict(size=10)),
    row=2, col=2
)

# Actualizar el layout del gráfico
fig.update_layout(
    title_text='Distribuciones Cíclicas',
    height=800,
    width=800,
    showlegend=False
)

# Mostrar el gráfico
fig.show()

La matriz de correlación revela que la hora tiene un impacto significativo en el volumen total de transacciones. La correlación negativa de las variables sin_hora y cos_hora indica una tendencia hacia un mayor número de transacciones en el tercer cuadrante de la representación cíclica, correspondiente al mediodía. Esto refuerza los hallazgos observados en gráficos anteriores.

Además, se identifica una correlación positiva entre la variable sin_dia_semana, lo que indica que en el primer y cuarto cuadrante, que corresponden a los días martes, miércoles y jueves, se experimenta el mayor volumen de transacciones, lo cual está alineado con los patrones visualizados previamente en los gráficos. En contraste, las variables sin_dia_mes y cos_dia_mes muestran una correlación muy baja, sugiriendo que su impacto en el modelo es mínimo. Por lo tanto, se procede a eliminar estas características, ya que no aportan información relevante para la predicción y podrían introducir ruido en el análisis.

Finalmente, se destaca que entre las variables dependientes no hay redundancia, lo que refuerza la integridad del modelo en términos de predictibilidad y variabilidad.

Actividad 2¶

División de los datos de entrenamiento y test¶

In [27]:
# Division de los datos disponibles
X = df_desglosado.drop(columns=['Transacciones']).values
y = df_desglosado['Transacciones'].values

seed = 33

X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=seed)
print(X_train.shape, X_test.shape, y_train.shape)
(6912, 8) (1728, 8) (6912,)

Definición de modelos en diccionario¶

In [28]:
# Definir los modelos en un diccionario
modelos = {
    'KNeighborsRegressor': KNeighborsRegressor(),
    'GradientBoostingRegressor': GradientBoostingRegressor(random_state = seed),
    'DecisionTree': DecisionTreeRegressor(random_state= seed),
    'MLP': MLPRegressor(max_iter=1000, random_state= seed),
}

# Definir los espacios de hiperparámetros para cada modelo
parametros = {
    'KNeighborsRegressor': {
        'n_neighbors' : [5,7,9,11,13,15],
        'weights': ['uniform', 'distance'],
        'algorithm': ['ball_tree', 'kd_tree', 'brute']
    },
    'GradientBoostingRegressor': {
        'min_samples_split': [2, 5, 10],
        'min_samples_leaf': [1, 2, 4],
        'max_depth': [3, 5, 10, 20],
        'max_leaf_nodes': [None, 10, 20, 30],
        'max_features': ['sqrt', 'log2'],
        'learning_rate': [0.001, 0.01, 0.1],
        'n_estimators': [50, 100, 200],
        'subsample': [0.3, 0.5, 0.7, 1.0],
    },
    'DecisionTree': {
        'max_depth': [None, 5, 10, 20],
        'min_samples_split': [2, 5, 10],
        'min_samples_leaf': [1, 2, 4]
    },
    'MLP': {
        'hidden_layer_sizes': [(50,), (100,), (100, 50)],
        'activation': ['tanh', 'relu', 'logistic'],
        'solver': ['lbfgs'],
        'alpha': [0.0001, 0.001, 0.01]
    }
    
}

Entrenamiento y comparación de modelos¶

In [29]:
mlflow.set_tracking_uri("http://localhost:5000")  # Asegúrate de que el servidor de MLflow esté corriendo (mlflow server)
mlflow.set_experiment("Comparacion_Modelos_Regresion")

mejores_modelos = {}
resultados = []

for nombre_modelo in modelos:
    with mlflow.start_run(run_name=nombre_modelo):
        modelo = modelos[nombre_modelo]
        param_grid = parametros[nombre_modelo]
        
        # Configurar la búsqueda de hiperparámetros
        grid_search = GridSearchCV(
            estimator=modelo,
            param_grid=param_grid,
            scoring='neg_mean_squared_error',
            n_jobs = -1
        )
        
        # Entrenar el modelo
        grid_search.fit(X_train, y_train)
        
        # Obtener el mejor modelo
        mejor_modelo = grid_search.best_estimator_
        mejores_modelos[nombre_modelo] = mejor_modelo
        
        # Predicciones
        y_pred = mejor_modelo.predict(X_test)
        
        # Calcular métricas
        mse = mean_squared_error(y_test.reshape(-1,1), y_pred.reshape(-1,1))
        mae = mean_absolute_error(y_test.reshape(-1,1), y_pred.reshape(-1,1))
        r2 = r2_score(y_test.reshape(-1,1), y_pred.reshape(-1,1))
        
        # Registrar hiperparámetros y métricas en MLflow
        mlflow.log_param("Modelo", nombre_modelo)
        mlflow.log_params(grid_search.best_params_)
        mlflow.log_metric("MSE", mse)
        mlflow.log_metric('RMSE', np.sqrt(mse))
        mlflow.log_metric("MAE", mae)
        mlflow.log_metric("R2", r2)
        
        # Registrar el modelo
        if nombre_modelo == 'TensorFlow_FFNN':
            # Para TensorFlow, guarda el modelo usando el flavor de TensorFlow
            mlflow.keras.log_model(mejor_modelo.model_, "modelo")
        else:
            mlflow.sklearn.log_model(mejor_modelo, "modelo")
        
        # Guardar resultados para comparación
        resultados.append({
            'Modelo': nombre_modelo,
            'MSE': mse,
            'RMSE': np.sqrt(mse),
            'MAE': mae,
            'R2': r2
        })

# Convertir resultados a un DataFrame para fácil comparación
df_resultados = pd.DataFrame(resultados)
df_resultados
2024/10/16 21:36:11 WARNING mlflow.models.model: Model logged without a signature and input example. Please set `input_example` parameter when logging the model to auto infer the model signature.
2024/10/16 21:36:11 INFO mlflow.tracking._tracking_service.client: 🏃 View run KNeighborsRegressor at: http://localhost:5000/#/experiments/177937010569710394/runs/d0e35d2a541e4b02945b27182a3993c3.
2024/10/16 21:36:11 INFO mlflow.tracking._tracking_service.client: 🧪 View experiment at: http://localhost:5000/#/experiments/177937010569710394.
2024/10/16 21:46:40 WARNING mlflow.models.model: Model logged without a signature and input example. Please set `input_example` parameter when logging the model to auto infer the model signature.
2024/10/16 21:46:40 INFO mlflow.tracking._tracking_service.client: 🏃 View run GradientBoostingRegressor at: http://localhost:5000/#/experiments/177937010569710394/runs/2d415f772704415180ff6c540ccd4d71.
2024/10/16 21:46:40 INFO mlflow.tracking._tracking_service.client: 🧪 View experiment at: http://localhost:5000/#/experiments/177937010569710394.
2024/10/16 21:46:42 WARNING mlflow.models.model: Model logged without a signature and input example. Please set `input_example` parameter when logging the model to auto infer the model signature.
2024/10/16 21:46:42 INFO mlflow.tracking._tracking_service.client: 🏃 View run DecisionTree at: http://localhost:5000/#/experiments/177937010569710394/runs/b5ce442559ca49f7b46dd73a776ae81c.
2024/10/16 21:46:42 INFO mlflow.tracking._tracking_service.client: 🧪 View experiment at: http://localhost:5000/#/experiments/177937010569710394.
2024/10/16 21:51:14 WARNING mlflow.models.model: Model logged without a signature and input example. Please set `input_example` parameter when logging the model to auto infer the model signature.
2024/10/16 21:51:14 INFO mlflow.tracking._tracking_service.client: 🏃 View run MLP at: http://localhost:5000/#/experiments/177937010569710394/runs/e4681ce4c7a8463a803b91e6a3f45d7c.
2024/10/16 21:51:14 INFO mlflow.tracking._tracking_service.client: 🧪 View experiment at: http://localhost:5000/#/experiments/177937010569710394.
Out[29]:
Modelo MSE RMSE MAE R2
0 KNeighborsRegressor 96.100624 9.803093 5.715428 0.810706
1 GradientBoostingRegressor 33.176403 5.759896 3.680109 0.934651
2 DecisionTree 43.326739 6.582305 3.805665 0.914657
3 MLP 40.268405 6.345739 4.029869 0.920682

Predicción mes de octubre con mejor modelo¶

In [30]:
# Prediccion del mes de octubre con el mejor modelo

dias = np.arange(1,32)
horas = np.arange(0,24)
minutos = np.arange(0,60, step = 15)

# Función para obtener el día de la semana (lunes=0, domingo=6)
def dia_semana(d):
    return (d) % 7

october = []

for d in dias:
    for h in horas:
        for m in minutos:
            # complete october with this cols [sin_hora	cos_hora	sin_min	cos_min	sin_dia_mes	cos_dia_mes	sin_dia_semana	cos_dia_semana]
            sin_hora = np.sin(2 * np.pi * h / 24)
            cos_hora = np.cos(2 * np.pi * h / 24)
            sin_min = np.sin(2 * np.pi * m / 60)
            cos_min = np.cos(2 * np.pi * m / 60)
            sin_dia_mes = np.sin(2 * np.pi * d / 31)
            cos_dia_mes = np.cos(2 * np.pi * d / 31)
            dia_semana_num = dia_semana(d)
            sin_dia_semana = np.sin(2 * np.pi * dia_semana_num / 7)
            cos_dia_semana = np.cos(2 * np.pi * dia_semana_num / 7)
            
            # Agregar fila con las columnas calculadas
            october.append([sin_hora, cos_hora, sin_min, cos_min, sin_dia_mes, cos_dia_mes, sin_dia_semana, cos_dia_semana])

# Convertir la lista a un DataFrame de pandas
october_df = pd.DataFrame(october, columns=['sin_hora', 'cos_hora', 'sin_min', 'cos_min', 'sin_dia_mes', 'cos_dia_mes', 'sin_dia_semana', 'cos_dia_semana'])

# Mostrar las primeras filas del DataFrame
october_df.head()
Out[30]:
sin_hora cos_hora sin_min cos_min sin_dia_mes cos_dia_mes sin_dia_semana cos_dia_semana
0 0.000000 1.000000 0.000000e+00 1.000000e+00 0.201299 0.97953 0.781831 0.62349
1 0.000000 1.000000 1.000000e+00 2.832769e-16 0.201299 0.97953 0.781831 0.62349
2 0.000000 1.000000 5.665539e-16 -1.000000e+00 0.201299 0.97953 0.781831 0.62349
3 0.000000 1.000000 -1.000000e+00 -1.836970e-16 0.201299 0.97953 0.781831 0.62349
4 0.258819 0.965926 0.000000e+00 1.000000e+00 0.201299 0.97953 0.781831 0.62349
In [31]:
model = mejores_modelos['GradientBoostingRegressor']

# Agregar columna de día (1-31) al DataFrame para que se pueda agrupar
october_df['dia_mes'] = np.repeat(np.arange(1, 32), 24 * 4)  # 24 horas * 4 intervalos de 15 minutos por día
october_df['hour'] = np.tile(np.repeat(np.arange(24), 4), 31)  # 24 hours, repeated 4 times (for 15-minute intervals), and repeated for 31 days

# Hacer predicciones usando el modelo
predicciones = model.predict(october_df.drop(columns=['dia_mes','hour']))

# Agregar las predicciones al DataFrame
october_df['prediccion'] = np.round(predicciones)
october_df.loc[october_df["prediccion"] < 0, 'prediccion'] = 0
# Agrupar las predicciones por día y calcular la media diaria
predicciones_por_dia = october_df.groupby('dia_mes')['prediccion'].sum()
predicciones_por_hora = october_df.groupby('hour')['prediccion'].sum()
# Mostrar las predicciones agrupadas por día
print(predicciones_por_dia.values)
[2297. 2329. 2302. 2152. 1689. 1447. 2244. 1959. 2094. 2164. 2173. 1774.
 1636. 2282. 2171. 2315. 2209. 2143. 1699. 1560. 2260. 2012. 1833. 2153.
 2071. 1583. 1373. 2218. 2158. 2134. 2331.]

Actividad 3¶

In [32]:
df_resultados
Out[32]:
Modelo MSE RMSE MAE R2
0 KNeighborsRegressor 96.100624 9.803093 5.715428 0.810706
1 GradientBoostingRegressor 33.176403 5.759896 3.680109 0.934651
2 DecisionTree 43.326739 6.582305 3.805665 0.914657
3 MLP 40.268405 6.345739 4.029869 0.920682

Antes de tomar una decisión sobre el modelo a utilizar para el pronóstico, se realizó una evaluación exhaustiva de una serie de modelos de regresión. Este enfoque empírico permitió comparar y contrastar el desempeño de diferentes algoritmos en el mismo conjunto de datos, asegurando que la elección final se basara en resultados cuantificables.

Para evaluar la precisión de los modelos entrenados, se consideraron las métricas de Mean Absolute Error (MAE) y Root Mean Squared Error (RMSE). El MAE mide la magnitud promedio de los errores en un conjunto de predicciones, sin tener en cuenta su dirección, lo que lo hace fácilmente interpretable. Por otro lado, el RMSE penaliza más fuertemente los errores grandes, lo que puede ser útil si es necesario evitar grandes desviaciones. Los modelos elegidos para el conjunto a ser evaluado son los siguientes:

  • KNeighborsRegressor El KNeighborsRegressor fue incluido en el conjunto de modelos debido a su simplicidad y eficacia en datasets pequeños. Este algoritmo no requiere entrenamiento explícito, lo que le permite adaptarse rápidamente a nuevos datos. Es particularmente efectivo en casos donde las relaciones entre los datos son locales y no lineales. Sin embargo, su desempeño puede verse afectado por la elección del número de vecinos y la escala de las características, lo que puede ser un inconveniente en ciertos escenarios.

  • Decision Tree El Decision Tree es un modelo interpretativo que divide el espacio de características en segmentos, facilitando la comprensión de la lógica detrás de las decisiones. Se eligió para el conjunto de modelos porque es fácil de interpretar y puede manejar tanto datos numéricos como categóricos. Aunque tiende a sobreajustar en datasets más pequeños, la limitación de hiperparámetros puede mitigar dicho riesgo.

  • MLP (Multi-Layer Perceptron) El MLP fue incluido en el conjunto de modelos debido a su capacidad para modelar relaciones complejas a través de capas ocultas. Su uso es beneficioso para datasets pequeños, ya que puede capturar patrones no lineales que otros modelos más simples pueden pasar por alto. Sin embargo, su rendimiento depende de la arquitectura y los hiperparámetros elegidos, lo que puede complicar su optimización en comparación con otros modelos. Además, puede ser propenso a sobreajustar si no se maneja adecuadamente.

  • Gradient Boosting Regressor El Gradient Boosting Regressor se destaca como el único modelo de ensamblado evaluado. Su inclusión en el conjunto se basa en su capacidad para combinar múltiples árboles de decisión, lo que mejora la precisión y robustez de las predicciones. Este enfoque permite que el modelo aprenda de los errores de los árboles anteriores, lo que lo hace muy efectivo en datasets pequeños donde la variabilidad puede ser alta. Además, su rendimiento superior en términos de MAE y RMSE indica que puede manejar la complejidad de los datos de manera más efectiva, minimizando tanto el sesgo como la varianza. Estas características lo convierten en una elección sólida para realizar pronósticos.

' Decisión El Gradient Boosting Regressor se destacó con un MAE de 3.714910 y un RMSE de 5.839456, lo que sugiere que proporciona predicciones más precisas en comparación con los otros modelos.

Actividad 4¶

In [33]:
# Crear el gráfico de barras con las predicciones por día
fig = px.bar(predicciones_por_dia, 
             x=predicciones_por_dia.index, 
             y=predicciones_por_dia.values,
             labels={'x': 'Día del Mes', 'y': 'Predicción'},
             title="Predicciones por día en octubre")

# Mostrar el gráfico
fig.show()
In [34]:
# Crear el gráfico de barras con las predicciones por día
print(predicciones_por_hora.values)
fig = px.bar(predicciones_por_hora, 
             x=np.arange(0,24), 
             y=predicciones_por_hora.values,
             labels={'x': 'Hora', 'y': 'Predicción'},
             title="Predicciones por hora en octubre")

# Mostrar el gráfico
fig.show()
[3.300e+01 1.000e+00 8.000e+00 4.900e+01 4.000e+01 5.800e+01 4.600e+01
 9.900e+01 1.734e+03 2.158e+03 3.135e+03 3.801e+03 6.876e+03 9.509e+03
 6.120e+03 4.002e+03 3.928e+03 4.426e+03 5.090e+03 4.765e+03 3.629e+03
 2.717e+03 4.770e+02 6.400e+01]
  • Sobre los patrones encontrados:
  1. Consistencia en las Predicciones: A lo largo del mes, las predicciones muestran una relativa estabilidad, con variaciones moderadas entre los días. Esto sugiere que el modelo ha captado patrones subyacentes de los datos, reflejando que no se esperan grandes fluctuaciones en las predicciones diarias.

  2. Máximos y Mínimos: Se observan picos y valles locales que evidencian patrones cíclicos tanto a nivel semanal como mensual. A nivel semanal, los máximos locales tienden a concentrarse entre miércoles, jueves y viernes, sugiriendo un aumento en la actividad durante estos días. A nivel mensual, se aprecian máximos locales al inicio, a mediados y al final del mes, lo cual podría estar relacionado con fechas comunes de pago de sueldos, lo que incrementa la actividad en estos periodos específicos.

  • Consideraciones para Resolver el Problema
  1. Selección de Modelos: Se optó por un enfoque empírico, entrenando y evaluando múltiples modelos para identificar el que mejor se adaptara a los datos. Se consideraron factores como la complejidad del modelo, la capacidad para manejar datos no lineales y la interpretabilidad de los resultados.

  2. Tamaño del Dataset: La elección de modelos se basó en el tamaño del dataset. Los modelos seleccionados, como el Gradient Boosting Regressor y Decision Trees, son particularmente eficaces en conjuntos de datos más pequeños, donde pueden aprender patrones relevantes sin sobreajustar.

  3. Validación y Ajuste de Hiperparámetros: Se implementaron técnicas de validación cruzada y ajuste de hiperparámetros para optimizar el rendimiento de los modelos, asegurando que se minimizan los errores de predicción y se maximiza la precisión.

  4. Data enginering: Debido a los patrones cíclicos identificados en las horas del día, los días de la semana y los días del mes, se decidió transformar estas variables utilizando funciones seno y coseno. Esta transformación permite a los modelos capturar de manera más efectiva los comportamientos cíclicos inherentes a los datos. Al representar estas variables de forma circular, los modelos pueden identificar mejor las tendencias repetitivas a lo largo del tiempo, lo cual es crucial dado que los datos abarcan únicamente un periodo de 3 meses.

  5. Descarte de Variables No Significativas: Dado que el dataset solo cubre un intervalo de 3 meses, variables como el año o el mes no aportan valor significativo al modelo predictivo. Estas variables fueron descartadas para evitar la inclusión de información irrelevante que podría afectar negativamente la precisión del modelo. Al no haber suficiente variación temporal en el dataset, mantener dichas variables no beneficiaría la predicción y podría introducir ruido.